iT邦幫忙

2023 iThome 鐵人賽

1
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 37

【Docker】撰寫 Dockerfile 製作映像檔(以 Spring Boot 為例)

  • 分享至 

  • xImage
  •  

在之前的文章,都是使用別人做好的映像檔。而上一篇更提到前後端同事可彼此共享映像檔來合作開發。本文將會介紹 Dockerfile,經由撰寫這份檔案,能將我們自己寫好的程式成品,製作成映像檔。

由於筆者的專業是後端開發,故本文將以 Spring Boot 應用程式的產出,也就是 JAR 檔,來做為示範對象。過程中會準備程式專案,以及介紹多個 Dockerfile 指令,文章篇幅較長。

此篇亦轉載到個人部落格


一、準備程式專案

本文將以 Spring Boot 後端程式所建置出的 JAR 檔來示範,本節讓我們準備好程式專案。此專案的功能很單純,就只是提供一個 REST API,回傳「application.properties」配置檔的內容。

(一)準備配置檔

在專案的「src/main/resources」路徑下,能找到「application.properties」配置檔。我們平常會在裡面存放一些參數,例如資料庫等外部服務的地址,或是帳號密碼等。

請在 application.properties 檔添加範例內容:

spring.datasource.username=local
spring.mail.username=local@gmail.com
logging.file.path=./log

其中「logging.file.path」參數是用來定義 log 檔的存放路徑。

程式的成品會部署(deploy)在不同 server 運行,如開發、測試或生產環境。讓我們根據不同的環境,準備不同參數的配置檔。

請在專案的根目錄建立叫做「env-config」的資料夾,並在當中建立名為「dev」、「test」與「prod」的子資料夾。接著在這些子資料夾下,建立各自的 application.properties 檔。

到目前為止,專案的結構示意如下:

|_ env-config
|   |_ dev
|   |   |_ application.properties
|   |_ test
|   |   |_ application.properties
|   |_ prod
|       |_ application.properties
|_ src
    |_ main
        |_ resources
            |_ application.properties

以下為 dev 的 application.properties 內容,可在開發過程中用於前後端串接。

spring.datasource.username=dev
spring.mail.username=dev@gmail.com
logging.file.path=./log

以下為 test 的 application.properties 內容,用於測試環境。

spring.datasource.username=test
spring.mail.username=test@gmail.com
logging.file.path=./log

以下為 prod 的 application.properties 內容,用於生產環境。

spring.datasource.username=prod
spring.mail.username=prod@gmail.com
logging.file.path=./log

(二)提供 REST API

以下的 controller 提供一個 API,會回傳配置檔的內容。同時也在 log 檔寫入簡單的文字。

@RestController
public class MyController {
    private static final Logger logger = LoggerFactory.getLogger(MyController.class);

    @Value("${spring.datasource.username}")
    private String dbUser;

    @Value("${spring.mail.username}")
    private String mailUser;

    @Value("${logging.file.path}")
    private String logPath;

    @GetMapping("/configs")
    public Map<String, String> getConfig() {
        logger.info("REST API is called, returning configs. {}", LocalTime.now());
        return Map.of(
                "dbUser", dbUser,
                "mailUser", mailUser,
                "logPath", logPath
        );
    }
}

(三)打包成 JAR 檔

假設以 Maven 做為建置工具,則執行 mvn clean package 指令,可將專案打包成 JAR 檔。此指令還會順道測試 Spring Boot 能否正常啟動。

完成後,在專案根目錄的「target」資料夾可找到 JAR 檔。讓我們重新命名為「backend-app.jar」,較為簡潔。

到目前為止,專案的結構示意如下:

|_ env-config
|_ src
|_ target
    |_ backend-app.jar

(四)規劃啟動方式

Docker 容器提供軟體的運行環境,因此我們要規劃上述的檔案在容器中要如何擺放,以及 JAR 檔的啟動方式。

在前面幾個小段,準備了三個 application.properties 配置檔。實際操作時,讀者可根據想執行的環境,選擇其中一個。

在本文,筆者規劃 JAR 檔、配置檔與 log 資料夾的相對位置如下。

|_ backend-app.jar
|_ config
|   |_ application.properties
|_ log

而以下範例是引入外部配置檔的啟動指令。

java -jar -Dspring.config.location=config/ backend-app.jar --spring.config.name=application

其中「-Dspring.config.location」參數定義了配置檔的位置;「--spring.config.name」參數決定要取用「application.properties」配置檔。

若有多個配置檔,例如切分出一個「mail.properties」,則「--spring.config.name」參數值以逗號串聯,如「application,mail」

二、撰寫 Dockerfile

上一節的事前準備,篇幅較長。本節正式開始撰寫 Dockerfile,打造容器的內部環境。文末也會提供本文完成後的 Dockerfile。

首先請在專案根目錄下建立名為「Dockerfile」的檔案(不需要有副檔名)。該檔案是由多個操作指令所構成,完成後,可透過 docker image build 指令建置出映像檔。

本節會介紹 Dockerfile 的少數指令,以簡短的方式完成它,讓讀者體會效果。而第四節將繼續介紹其他實用的指令來做優化。

(一)FROM 指令

容器提供了軟體的運行環境,使用 FROM 指令,相當於把指定的環境搬進容器中。

Spring Boot 3 需要 Java 17 環境,我們可到 Dockerhub 上尋找合適的 Java 的映像檔。筆者選用 OpenJDK,而 tag 採用「17-oracle」。

請回到 Dockerfile 寫下第一行操作:

FROM openjdk:17-oracle

若讀者進一步了解,會發現 openjdk 本身也有自己的 Dockerfile。它的第一行為:

FROM oraclelinux:8-slim

代表是基於一個 Linux 環境。也就是說,我們可理解成現在指定的 openjdk:17-oracle,是一個裝有 Java 17 的 Linux 環境。

(二)COPY 指令

接下來要將軟體程式,也就是 JAR 檔與配置檔,搬到容器內。讓我們先回顧一下目前程式專案的結構。

|_ env-config
|   |_ dev
|   |   |_ application-test.properties
|   |_ test
|   |   |_ application-test.properties
|   |_ prod
|       |_ application-prod.properties
|_ src
|_ target
|   |_ backend-app.jar
|_ Dockerfile

使用 COPY 指令,可將主機的檔案與子資料夾複製進容器。指令寫法為 COPY {來源路徑1} {來源路徑2}... {目標路徑}

別忘了容器內部是個 Linux 系統,所以裡面當然會有一些系統檔案。為了不要把 Spring Boot 的相關檔案與它們混在一起,筆者設立一個專屬資料夾,叫做「app」。

以下兩個 COPY 指令的用途,分別是:

  • 將 JAR 檔複製到容器的「/app」路徑下。
  • 將 prod 資料夾下的所有檔案與子資料夾,複製到容器的「/app/config」路徑下。
# ...
COPY ./target/backend-app.jar /app/
COPY ./env-config/prod /app/config/

若容器的目標資料夾不存在,Docker 會自動建立。

讀者也可採用陣列的格式撰寫 COPY 指令,提升可讀性。

COPY ["./target/backend-app.jar", "/app/"]
COPY ["./env-config/prod", "/app/config/"]

(三)EXPOSE 指令

在使用 Docker 前,電腦上運行的軟體服務本來都會有自己的 port 號。例如 MySQL 是 3306、Redis 是 6309。現在將軟體移動到容器中,那我們就需要定義該容器的 port 號,以接收請求。

Spring Boot 預設的 port 號為 8080。使用 EXPOSE 指令進行定義的寫法如下:

# ...
EXPOSE 8080

(四)CMD 指令

不論讀者所開發的是前端還是後端,都能在 command line 執行指令,來啟動自己寫好的程式。例如前端框架 Angular 的啟動指令為 ng serve

而我們在第一節的最後,決定要透過以下指令來啟動 Spring Boot 的 JAR 檔。

java -jar -Dspring.config.location=config/ backend-app.jar --spring.config.name=application

Dockerfile 的 CMD 指令,是定義容器啟動時,要在 command line 執行什麼指令。通常是用來啟動裡面的軟體,以 Spring Boot 為例,範例寫法如下:

# ...
CMD ["java", "-Dspring.config.location=config/", "-jar", "backend-app.jar", "--spring.config.name=application"]

要注意的是,如果 Dockerfile 有多個 CMD,則只有最後一個會生效。若讀者想執行多個指令,不妨事先準備一份 shell script,複製進容器中使用。

(五)WORKDIR 指令

延續上一段的 CMD,事實上容器預設會在根目錄執行指令。所以我們得考慮當前的工作目錄是否正確,以免相對路徑指向錯誤的位置。

WORKDIR 指令的用途,便是切換工作目錄,類似 Linux 系統的「cd」指令。

以下範例是切換到存放 JAR 檔的「app」資料夾,再執行指令。

# ...
WORKDIR /app
CMD ...

三、建立映像檔

經過第二節的操作,總算寫好了 Dockerfile,整理如下。

FROM openjdk:17-oracle
COPY ["./target/backend-app.jar", "/app/"]
COPY ["./env-config/prod", "/app/config/"]
EXPOSE 8080
WORKDIR /app
CMD ["java", "-Dspring.config.location=config/", "-jar", "backend-app.jar", "--spring.config.name=application"]

有了這份檔案,我們就能建立出程式專案的映像檔。Docker 指令寫法為
docker image build -t {映像檔名稱} -f {Dockerfile 檔名} {Dockerfile 所在路徑}

若讀者已知該映像檔是會被上傳到 Docker Hub 的,可順便在映像檔名稱加上帳號與 tag。

範例指令如下。

docker image build -t spring-demo:1.0.0 .

該指令預設會從路徑下尋找名為「Dockerfile」的檔案。若讀者當初取了其他名稱,需額外用 -f 參數指定檔名。

稍等一段時間,就建立完成了。

四、其他 Dockerfile 指令

本節將介紹其他實用的 Dockerfile 指令,讓讀者有更多認識。

(一)ENV 指令

ENV 指令的用途是宣告容器中 Linux 系統中的環境變數。此外,當 Dockerfile 中有多個相同的值,可將它們抽為常數,便於維護。以本文的例子來看,「/app」字串出現了三次,讓我們進行調整。

該指令的寫法為 ENV {名稱}={值},而需要用到常數的地方,則以 ${名稱} 的寫法取代。範例如下:

# ...
ENV APP_DIR=/app
COPY ["./target/backend-app.jar", "${APP_DIR}/"]
COPY ["./env-config/prod", "${APP_DIR}/config/"]
# ...
WORKDIR ${APP_DIR}
# ...

以上宣告了名為「APP_DIR」的常數。

(二)ARG 指令

在範例中的 COPY 操作,有個步驟是將 prod 環境的配置檔複製到容器中。若希望指定路徑的部份,能夠設計成「傳入參數」,就不必根據不同的環境,準備多個 Dockerfile 了。

ARG 指令能夠幫助做到這件事。在 Dockerfile 中,我們可將用到參數值的地方「挖空」,建立映像檔時,再從 docker image build 指令,把實際的值傳入。

該指令的寫法為 ARG {名稱},而需要用到參數的地方,則以 ${參數名稱} 的寫法取代。範例如下:

# ...
ARG SERVER_TYPE
ENV APP_DIR=/app
# ...
COPY ["./env-config/${SERVER_TYPE}", "${APP_DIR}/config/"]
# ...

以上宣告了名為「SERVER_TYPE」的參數。

後續建立映像檔時,可在 Docker 指令透過 --build-arg {名稱}={值} 的參數傳入。寫法如下

docker image build -t spring-demo:1.0.0 --build-arg SERVER_TYPE=prod .

若讀者想取用 dev 資料夾下的配置檔,則寫成 --build-arg SERVER_TYPE=dev

(三)VOLUME 指令

還記得在第一節第一段有配置 log 檔的存放路徑嗎?若想將該路徑掛載到 volume,以便達到持久化,可在建立容器的 docker container create 指令使用 -v--mount 參數做設定。

或者我們也能在 Dockerfile 中,使用 VOLUME 指令,讓容器在初次啟動時主動掛載。

該指令的寫法為 VOLUME {容器絕對路徑},範例如下:

# ...
ENV APP_DIR=/app
# ...
VOLUME ${APP_DIR}/log

此處特別強調是容器的絕對路徑。即便前面正好有使用 WORKDIR 指令,但那是在 CMDRUN 執行 command line 指令時,所一起配合的。跟 VOLUME 無關。

(四)RUN 指令

RUN 指令的用途,相當於在 command line 執行 Linux 指令。若需要對複製進容器的檔案做解壓縮(tar)、移動(mv)等,甚至執行 shell script,抑或是前端程式可能要跑 npm install 下載套件,均可用 RUN 來處理,

本文的 Spring Boot 暫無必須要用 RUN 的地方。以下的範例,是刻意建立一個存放 log 的資料夾(雖然 Spring Boot 本身也會自動建立)。

# ...
ENV APP_DIR=/app
RUN ["mkdir", "${APP_DIR}"] && ["mkdir", "${APP_DIR}/log"]

若有多個指令想執行,可用「&&」符號串聯。

(五)LABEL 指令

LABEL 指令能為映像檔添加一些資訊,例如描述、作者等等。

該指令的寫法為 LABEL 欄位=值,以下為範例。

# ...
LABEL description="A demo Spring Boot application." \
  maintainer="foobar@gmail.com"

這些資訊可透過 docker image inspect 指令查看映像檔詳情時,從「Config.Labels」欄位找到。

[
    {
        "...": "...",
        "Config": {
            "...": "...",
            "Labels": {
                "description": "A demo Spring Boot application.",
                "maintainer": "foobar@gmail.com"
            }
        },
        "...": "..."
    }
]

(六)HEALTHCHECK 指令

HEALTHCHECK 指令的用途,是設定讓 Docker 定期檢查容器的「健康狀態」。什麼是健康狀態呢?有時容器可能還在運行,但裡面的軟體卻無法正常提供服務了,此時我們視為不健康。

容器的健康狀態,可經由執行 docker container ls 指令,從「STATUS」欄位看到。健康時會顯示「healthy」,否則會顯示「unhealthy」提醒我們。
docker-container-list-indicate-healthy.png
https://ithelp.ithome.com.tw/upload/images/20231214/20131107xryXpWT4VY.png

容器不健康的定義,要由開發者自行去決定,比方說網頁無法顯示、連不上資料庫、通知送不出去等。

首先直接來看看 HEALTHCHECK 指令該如何撰寫,接著再逐一解析。

# ...
HEALTHCHECK --start-period=60s --interval=180s --timeout=10s --retries=1 \
  CMD ["curl", "-fs", "http://localhost:8080/configs"] \
  || exit 1

HEALTHCHECK 指令用到了一些參數,說明如下。

  • --start-period:容器啟動多久後,會進行第一次檢查。
  • --interval:檢查的時間間隔。
  • --timeout:逾時時間。
  • --retries:重試次數。
  • CMD:檢查的指令。
  • || exit:當 || 左邊的指令無法執行完成,則透過 exit 回傳檢查結果給 Docker。1 為不健康,2 為忽略該次結果。

根據上面的參數說明,此例健康檢查的定義為:

  • 容器啟動 60 秒後,每隔 180 秒進行一次健康檢查。
  • 檢查方式是執行 curl 指令,呼叫 REST API。
  • 若經過 10 秒鐘仍無法取得結果,視為逾時。
  • 若檢查結果為不健康,或逾時,可重新檢查 1 次。

以後端程式為例,為了進行健康檢查。可另外設計一個專門的 REST API 去做各項檢查,沒問題則回傳 200 系列的 HTTP 狀態碼。

五、小結

經過本文的一連串介紹,最終的 Dockerfile 如下。本節讓我們快速進行回顧。

FROM openjdk:17-oracle
ARG SERVER_TYPE
ENV APP_DIR=/app
RUN ["mkdir", "${APP_DIR}"] && ["mkdir", "${APP_DIR}/log"]
COPY ["./target/backend-app.jar", "${APP_DIR}/"]
COPY ["./env-config/${SERVER_TYPE}", "${APP_DIR}/config/"]
EXPOSE 8080
WORKDIR ${APP_DIR}
CMD ["java", "-Dspring.config.location=config/", "-jar", "backend-app.jar", "--spring.config.name=application"]
VOLUME ${APP_DIR}/log
LABEL description="A Spring Boot application sending test email." \
  maintainer="foo@gmail.com"
HEALTHCHECK --start-period=60s --interval=180s --timeout=10s --retries=1 \
  CMD ["curl", "http://localhost:8080/configs"] \
  || exit 1

首先準備好已安裝 Java 17 的 Linux 環境搬進容器中。接著將 Spring Boot 的 JAR 檔與配置檔複製到容器,並開放 8080 port。至於要選擇何種配置檔,則由建立映像檔時,在 Docker 指令以參數的方式傳入。

為了在容器啟動時能運行 JAR 檔,因此需切換到該工作目錄,於 command line 執行 java 指令。

最後將 volume 掛載到 log 資料夾,並填寫映像檔資訊,以及設定健康檢查的方式。


今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
【Docker】將容器打包成映像檔並上傳
下一篇
【Docker】利用 Docker Compose 完成多容器部署(一)
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
孤獨一隻雞
iT邦研究生 4 級 ‧ 2024-10-08 18:35:25

請問一定要先用 Maven 包成 jar 嗎? 沒辦法在包 docker image 時一起包嗎?

Chikuwa iT邦新手 2 級 ‧ 2024-10-10 16:08:13 檢舉

後來我有聽過另一種做法,是在寫 Dockerfile 時,用 RUN 操作去執行 Maven 的指令
想法大概如下:

# 其他操作...

# 包出 JAR 檔
RUN ["mvn", "clean", "package"]

# 將 JAR 檔複製到 Docker image 中
COPY ["./target/backend-app.jar", "app/"]

# 其他操作...

我要留言

立即登入留言